1 /**
2 Copyright: Copyright (c) 2021, Joakim Brännström. All rights reserved.
3 License: MPL-2
4 Author: Joakim Brännström (joakim.brannstrom@gmx.com)
5 
6 This Source Code Form is subject to the terms of the Mozilla Public License,
7 v.2.0. If a copy of the MPL was not distributed with this file, You can obtain
8 one at http://mozilla.org/MPL/2.0/.
9 
10 Code copied from dextool
11 */
12 module code_checker.database;
13 
14 import logger = std.experimental.logger;
15 import std.algorithm : map, joiner, filter;
16 import std.array : appender, array, empty;
17 import std.datetime : SysTime, Duration;
18 import std.exception : collectException;
19 import std.format : format;
20 import std.typecons : Nullable, Flag, No;
21 
22 import miniorm : Miniorm, select, insert, insertOrReplace, delete_,
23     insertOrIgnore, toSqliteDateTime, fromSqLiteDateTime, Bind;
24 import my.named_type;
25 import my.optional;
26 import my.path;
27 import my.hash : Checksum64;
28 
29 import code_checker.database.schema;
30 
31 /** Database wrapper with minimal dependencies.
32  */
33 struct Database {
34     package Miniorm db;
35 
36     /** Create a database by either opening an existing or initializing a new.
37      *
38      * Params:
39      *  db = path to the database
40      */
41     static auto make(AbsolutePath db) @safe {
42         return Database(initializeDB(db));
43     }
44 
45     auto transaction() @trusted {
46         return db.transaction;
47     }
48 
49     DbDependency dependencyApi() return @safe {
50         return typeof(return)(&db, &this);
51     }
52 
53     DbFile fileApi() return @safe {
54         return typeof(return)(&db, &this);
55     }
56 
57     DbCompileDbTrack compileDbTrackApi() return @safe {
58         return typeof(return)(&db, &this);
59     }
60 }
61 
62 struct DbFile {
63     private Miniorm* db;
64     private Database* wrapperDb;
65 
66     void put(const Path p, Checksum64 cs, SysTime lastModified) @trusted {
67         static immutable sql = format!"INSERT INTO %s (path, checksum, root, time_stamp)
68             VALUES (:path, :checksum, 1, :ts)
69             ON CONFLICT (path) DO UPDATE SET checksum=:checksum,time_stamp=:ts"(
70                 filesTable);
71         auto stmt = db.prepare(sql);
72         stmt.get.bind(":path", p.toString);
73         stmt.get.bind(":checksum", cast(long) cs.c0);
74         stmt.get.bind(":ts", lastModified.toSqliteDateTime);
75         stmt.get.execute;
76     }
77 
78     /// Returns: the file path that the id correspond to.
79     Nullable!TrackFile getFile(const FileId id) @trusted {
80         static immutable sql = format(
81                 "SELECT path,checksum,time_stamp FROM %s WHERE id = :id", filesTable);
82         auto stmt = db.prepare(sql);
83         stmt.get.bind(":id", id.get);
84 
85         typeof(return) rval;
86         foreach (ref r; stmt.get.execute)
87             rval = TrackFile(Path(r.peek!string(0)),
88                     Checksum64(r.peek!long(1)), r.peek!string(2).fromSqLiteDateTime);
89         return rval;
90     }
91 
92     Nullable!TrackFile getFile(const Path path) @trusted {
93         static immutable sql = format(
94                 "SELECT path,checksum,time_stamp FROM %s WHERE path=:path", filesTable);
95         auto stmt = db.prepare(sql);
96         stmt.get.bind(":path", path);
97 
98         typeof(return) rval;
99         foreach (ref r; stmt.get.execute)
100             rval = TrackFile(Path(r.peek!string(0)),
101                     Checksum64(r.peek!long(1)), r.peek!string(2).fromSqLiteDateTime);
102         return rval;
103     }
104 
105     Nullable!FileId getFileId(const Path p) @trusted {
106         static immutable sql = format("SELECT id FROM %s WHERE path=:path", filesTable);
107         auto stmt = db.prepare(sql);
108         stmt.get.bind(":path", p.toString);
109         auto res = stmt.get.execute;
110 
111         typeof(return) rval;
112         if (!res.empty)
113             rval = FileId(res.oneValue!long);
114         return rval;
115     }
116 
117     /// Remove the file with all mutations that are coupled to it.
118     void removeFile(const Path p) @trusted {
119         auto stmt = db.prepare(format!"DELETE FROM %s WHERE path=:path"(filesTable));
120         stmt.get.bind(":path", p.toString);
121         stmt.get.execute;
122     }
123 
124     /// Returns: all files tagged as a root.
125     FileId[] getRootFiles() @trusted {
126         static immutable sql = format!"SELECT id FROM %s WHERE root=1"(filesTable);
127 
128         auto app = appender!(FileId[])();
129         auto stmt = db.prepare(sql);
130         foreach (ref r; stmt.get.execute) {
131             app.put(r.peek!long(0).FileId);
132         }
133         return app.data;
134     }
135 
136     /// Returns: All files in the database as relative paths.
137     Path[] getFiles() @trusted {
138         auto stmt = db.prepare(format!"SELECT path FROM %s"(filesTable));
139         auto res = stmt.get.execute;
140 
141         auto app = appender!(Path[]);
142         foreach (ref r; res) {
143             app.put(Path(r.peek!string(0)));
144         }
145 
146         return app.data;
147     }
148 
149     Nullable!Checksum64 getFileChecksum(const Path p) @trusted {
150         static immutable sql = format!"SELECT checksum FROM %s WHERE path=:path"(filesTable);
151         auto stmt = db.prepare(sql);
152         stmt.get.bind(":path", p.toString);
153         auto res = stmt.get.execute;
154 
155         typeof(return) rval;
156         if (!res.empty) {
157             rval = Checksum64(res.front.peek!long(0));
158         }
159 
160         return rval;
161     }
162 }
163 
164 /** Dependencies between root and those files that should trigger a re-analyze
165  * of the root if they are changed.
166  */
167 struct DbDependency {
168     private Miniorm* db;
169     private Database* wrapperDb;
170 
171     /// The root must already exist or the whole operation will fail with an sql error.
172     void set(const Path path, const DepFile[] deps) @trusted {
173         static immutable insertDepSql = "INSERT INTO " ~ depFileTable ~ " (file,checksum,time_stamp)
174             VALUES(:file,:cs,:ts)
175             ON CONFLICT (file) DO UPDATE SET checksum=:cs,time_stamp=:ts WHERE file=:file";
176 
177         auto stmt = db.prepare(insertDepSql);
178         auto ids = appender!(long[])();
179         foreach (a; deps) {
180             stmt.get.bind(":file", a.file.toString);
181             stmt.get.bind(":cs", cast(long) a.checksum.c0);
182             stmt.get.bind(":ts", a.timeStamp.toSqliteDateTime);
183             stmt.get.execute;
184             stmt.get.reset;
185 
186             // can't use lastInsertRowid because a conflict would not update
187             // the ID.
188             auto id = getId(a.file);
189             if (id.hasValue)
190                 ids.put(id.orElse(0L));
191         }
192 
193         static immutable addRelSql = "INSERT OR IGNORE INTO " ~ depRootTable
194             ~ " (dep_id,file_id) VALUES(:did, :fid)";
195         stmt = db.prepare(addRelSql);
196         const fid = () {
197             auto a = wrapperDb.fileApi.getFileId(path);
198             if (a.isNull) {
199                 throw new Exception(
200                         "File is not tracked (is missing from the files table in the database) "
201                         ~ path);
202             }
203             return a.get;
204         }();
205 
206         foreach (id; ids.data) {
207             stmt.get.bind(":did", id);
208             stmt.get.bind(":fid", fid.get);
209             stmt.get.execute;
210             stmt.get.reset;
211         }
212 
213         // remove dropped relations
214         stmt = db.prepare(format!"DELETE FROM %s WHERE file_id=:fid AND dep_id NOT IN (%(%s,%))"(depRootTable,
215                 ids.data));
216         stmt.get.bind(":fid", fid.get);
217         stmt.get.execute;
218     }
219 
220     private Optional!long getId(const Path file) {
221         foreach (a; db.run(select!DependencyFileTable.where("file = :file",
222                 Bind("file")), file.toString)) {
223             return some(a.id);
224         }
225         return none!long;
226     }
227 
228     /// Returns: all dependencies.
229     DepFile[] getAll() @trusted {
230         return db.run(select!DependencyFileTable)
231             .map!(a => DepFile(Path(a.file), Checksum64(a.checksum), a.timeStamp)).array;
232     }
233 
234     /// Returns: all files that a root is dependent on.
235     Path[] get(const Path root) @trusted {
236         static immutable sql = format!"SELECT t0.file
237             FROM %1$s t0, %2$s t1, %3$s t2
238             WHERE
239             t0.id = t1.dep_id AND
240             t1.file_id = t2.id AND
241             t2.path = :file"(depFileTable,
242                 depRootTable, filesTable);
243 
244         auto stmt = db.prepare(sql);
245         stmt.get.bind(":file", root.toString);
246         auto app = appender!(Path[])();
247         foreach (ref a; stmt.get.execute) {
248             app.put(Path(a.peek!string(0)));
249         }
250 
251         return app.data;
252     }
253 
254     /// Remove all dependencies that have no relation to a root.
255     void cleanup() @trusted {
256         db.run(format!"DELETE FROM %1$s
257                WHERE id NOT IN (SELECT dep_id FROM %2$s)"(depFileTable,
258                 depRootTable));
259     }
260 }
261 
262 /// A file that a root is dependent on.
263 struct DepFile {
264     Path file;
265     Checksum64 checksum;
266     SysTime timeStamp;
267 }
268 
269 TrackFile toTrackFile(DepFile a) {
270     return TrackFile(a.file, a.checksum, a.timeStamp);
271 }
272 
273 /// Primary key in the files table
274 alias FileId = NamedType!(long, Tag!"FileId", long.init, Comparable, Hashable, TagStringable);
275 
276 struct TrackFile {
277     Path file;
278     Checksum64 checksum;
279     SysTime timeStamp;
280 }
281 
282 struct DbCompileDbTrack {
283     private Miniorm* db;
284     private Database* wrapperDb;
285 
286     void put(TrackFile f) {
287         static immutable sql = "INSERT OR REPLACE INTO " ~ compileDbTrack
288             ~ " (path,time_stamp,checksum) VALUES(:path,:ts,:cs)";
289 
290         auto stmt = db.prepare(sql);
291         stmt.get.bind(":path", f.file.toString);
292         stmt.get.bind(":ts", f.timeStamp.toSqliteDateTime);
293         stmt.get.bind(":cs", cast(long) f.checksum.c0);
294         stmt.get.execute;
295     }
296 
297     TrackFile get(const Path path) {
298         static immutable sql = "SELECT time_stamp,checksum FROM "
299             ~ compileDbTrack ~ " WHERE path=:path";
300         auto stmt = db.prepare(sql);
301         stmt.get.bind(":path", path);
302         auto rval = TrackFile(path);
303         foreach (ref a; stmt.get.execute) {
304             rval.timeStamp = a.peek!string(0).fromSqLiteDateTime.toLocalTime;
305             rval.checksum = a.peek!long(1).Checksum64;
306         }
307         return rval;
308     }
309 
310     /// Remove old entries to avoid infinite growth of the database.
311     void cleanup(const Duration dropAfter) {
312         import std.datetime : Clock, dur;
313 
314         static immutable sql = "DELETE FROM " ~ compileDbTrack
315             ~ " WHERE datetime(time_stamp) < datetime(:older_then)";
316 
317         auto stmt = db.prepare(sql);
318         // two is a magic number that I think is ok. Over two days not that
319         // many files should have been added/removed that the database grow to
320         // Gbyte in size.
321         stmt.get.bind(":older_then", (Clock.currTime - dropAfter).toSqliteDateTime);
322         stmt.get.execute;
323     }
324 }